Eligibility gate — Discord membership and minimum account age before a key is issued (lore 0189) - #230
Conversation
… key is issued
The issue path moves behind a fresh OAuth round-trip (action=issue in the
signed state): the callback checks Stellar Discord membership
(GET /users/@me/guilds/{guild}/member with the just-exchanged user token)
and the snowflake-derived account age against two operator-seeded SSM
parameters, and only then runs 0187's reconciler. Three outcomes, not two:
only Discord's own 10007/10004 on a 404 is 'not a member'; 401/403/429/5xx,
unrecognised shapes and an absent pending field refuse without accusation.
The /key route goes fully read-only — a session cookie alone can cause zero
control-plane writes — and answers no_key when there is nothing to reveal.
Scope becomes exactly 'identify guilds.members.read', compared as a set.
'Get my API key' becomes a top-level link into the issue round-trip; the key section fetches the (now read-only) reveal on mount and renders the five ?issue= landing states in the wording this task decides: not-a-member names the server and links discord.gg/stellardev, too-young renders the backend's wait_secs as a wait rather than a rejection, and could-not-verify is explicitly not an accusation. Landing params are one-shot — read once, stripped from the URL — which also closes 0186's O10 stale-banner item. The landing page states both prerequisites before the visitor authenticates.
…docs compute-stack passes PORTAL_GUILD_ID_PARAM and PORTAL_MIN_ACCOUNT_AGE_PARAM (names only, unconditional) and states the read grant explicitly beside the plan-id one. verify-openapi-routes gains check 7: the handler carries both names exactly, and no synthesized template may CREATE either parameter — a CDK-owned parameter would be restored by the next deploy, un-flipping production back to the test guild after 0179. The runbook gains §2a (the put-parameter seeding, the ownership split, the no-redeploy property) and its scope step is rewritten to the two-scope done state; the README gains the local procedure for the gate and its refusal states.
…asurements Step 0's tables stay empty with a dated deferral — the archived 0180 notes were found unmeasured and the prerequisites are operator-owned; the code is written to the documented safe rules with each unmeasured behaviour behind one reversible arm. Ticks the code-side acceptance criteria and names the two operator steps that remain.
All seven were verified against the code before acting; none was a false positive. The reconcile deadline is now derived from what is left of the invocation rather than reused from 0187, where the reconciliation was the whole request. With four network calls in front of it the worst case reached ~29s against a 15s Lambda, which answers an API Gateway 502 instead of ?issue=failed and can leave a key created but never attached. The authorize URL sends prompt=none. Three comments asserted that Discord does not re-prompt for consent on repeat authorisation and the parameter that makes that true was never sent, so every issue, every retry after a refusal and every future rework was a full consent screen — the cost the per-action model's own justification denied. ?issue=cancelled and ?issue=denied give the issue flow the pre-check pair sign-in has had since 0186. Without them a cancelled press landed on ?signin=cancelled, whose banner renders only in the signed-out branch an issue round-trip has by definition left: the visitor came back to an unchanged dashboard with nothing said. guild_id() and member_url now share one is_snowflake. Only the consumer checked the shape, so a guild name passed the cold-start probe and then refused every visitor as unknown, indefinitely. The task's parameter table named such a value, and is corrected with it. The key fetch runs once per load again — load() depended on an inline callback prop, so reporting the key re-fired the mount effect — and ?issue=ok no longer renders beside "you have no API key yet", a window that offered a second key to somebody who had just been given a first. Left alone deliberately: the wildcard apigateway:DELETE on /apikeys/*, which 0194's checklist already names verbatim, mitigation included.
Corrects two claims the round made stale — the redirect count and the test totals — and adds decisions 18 to 22 plus the two entries under Issues Encountered. The entry worth keeping is why a comment can be load-bearing and still describe code that was never written: three places asserted that Discord does not re-prompt for consent, and nothing sent prompt=none.
Neither was a criterion this slice's code could meet: both are manual prerequisites owned by the operator, and both remain recorded in Step 0's status note, in Future Work, and in the two runbooks they name. What is left is eleven criteria, all met, all asserted by tests.
karczuRF
left a comment
There was a problem hiding this comment.
Code review of the eligibility gate. The security-relevant core is sound — I checked and cleared the scope set-comparison and member_url snowflake validation (path injection closed), classify_member_response's three-outcome table, the snowflake epoch math and wait_secs ceiling, the ISSUE_BUDGET/RECONCILE_FLOOR derivation against the real 15s apiHandler.timeoutSeconds, the read-only lookup vs. create-capable issue_for split (the reconciler is semantically unchanged; only KeyValue is dropped from Outcome), UsageCache being Arc-backed so the cloned handle in IssueDeps evicts the same cache, the Action::Issue state-token round-trip and mismatch refusal, the lambda/aws-mtls feature gating, and useOneShotParams.
Five findings below; the first is the significant one.
Two documented risks I did not file as inline bugs, but that are worth the team's eyes:
pending: None->Unknownmeans that if Discord's REST member object omitspending, every member is refused as "could not verify", indefinitely. Lore task 0180 item 2 is still unmeasured, so this is a fail-closed-for-100%-of-users path resting on unverified Discord semantics.prompt=noneis now on the sharedauthorize_url, so it also changes first-time sign-in behaviour, not just issuance.
Reviewed as the top of a two-PR stack on top of #227. Diff is correctly scoped by the branch target; if #227 takes review fixes, this needs a rebase before merge.
| @@ -382,6 +451,14 @@ async fn callback( | |||
| Err(error) => return refuse_discord("token exchange", error, drop_pending), | |||
There was a problem hiding this comment.
Discord failures on an action=issue callback bypass the designed ?issue= redirect.
They fall through to refuse_discord, producing a bare 502 JSON page worded "could not complete sign-in" with no link back — instead of the ?issue=unknown / ?issue=failed redirect this PR's whole three-outcome design is built around. The visitor pressed "issue a key", not "sign in", and lands on a dead end.
Same hole at issue.rs:262 (the identity read), and in login's unwired 503.
Most likely trigger on this PR specifically: UnexpectedScope, if the Developer Portal registration still carries only identify while the new prompt=none suppresses the re-consent that would otherwise have granted guilds.members.read. That combination turns a config lag into an unexplained 502 rather than the "could not verify" path you designed for exactly this.
| */ | ||
| function describeWait(waitSecs: string | null): string { | ||
| const parsed = | ||
| waitSecs && /^\d{1,7}$/.test(waitSecs) ? Number(waitSecs) : NaN; |
There was a problem hiding this comment.
describeWait rejects rather than clamps large waits, understating them drastically.
The /^\d{1,7}$/ guard rejects any wait_secs over 7 digits. So a min-account-age-minutes above roughly 16 weeks renders a months-long wait as "about a few minutes" — the most misleading possible failure direction for a value an operator sets via put-parameter without a redeploy.
Clamping to the top bucket would fail in the honest direction.
| key yet" is the page contradicting itself about the one fact the | ||
| visitor came for. The settling branch below says what is true. */} | ||
| {issue === 'ok' && view.state !== 'none' && ( | ||
| <p data-testid="issue-ok">Your key is ready.</p> |
There was a problem hiding this comment.
?issue=ok can render above a failed reveal.
The banner is guarded only against view.state === 'none', so an error state still shows "Your key is ready." directly above "Could not get your API key" — the same self-contradiction the none guard was written to avoid.
The guard wants to be against the states where the key is not on screen, not just none.
| {view.state === 'none' && issue === 'ok' && ( | ||
| <p data-testid="issue-ok-settling"> | ||
| Your key was created, and is taking a moment to appear.{' '} | ||
| <button type="button" onClick={load}> |
There was a problem hiding this comment.
The settling "Check again" button gives no feedback on a retry that still finds nothing.
It doesn't reset view to loading, so pressing it when the key still hasn't settled produces zero visible change — the user can't tell the press registered, and the natural read is that the button is broken.
Usage's Refresh (line 774) does the opposite and is the right model here.
| for (const file of templateFiles) { | ||
| const tpl = readJson(join(cdkOut, file), 'synthesized template'); | ||
| for (const [id, resource] of resourcesOfType(tpl, 'AWS::SSM::Parameter')) { | ||
| const name = resource.Properties?.Name; |
There was a problem hiding this comment.
Check 7(b) is evadable by a non-literal parameter name.
It only inspects Properties.Name when that value is a literal string. A CDK-created SSM parameter whose name synthesizes to an Fn::Join / Fn::Sub — which is what you get the moment anyone interpolates the env into it, i.e. the natural way someone would write /prices/${env}/discord-guild-id — slips straight past the "never CDK-owned" guard.
That matters more than a usual CI gap, because the guard exists precisely to stop a cdk deploy from silently restoring a committed value and un-flipping production back to the test guild after lore task 0179 step 4. Treating any non-string Name as a failure (or resolving the intrinsic) would close it.
…er it The five findings from PR #230, plus the two risks it filed as prose and five more found re-reading the diff. The significant one: a Discord failure on an `action=issue` callback fell through to the sign-in arm's `502 "could not complete sign-in"` — a bare JSON page, about an action the visitor did not take, with no link back. `UnexpectedScope` lands on `?issue=denied`, the same landing Discord's own `invalid_scope` already reaches, because it is the same registration drift seen one step later; everything else lands on `?issue=unknown`. The unwired `/auth/login?action=issue` joins them at `?issue=failed` instead of a `503` envelope, and now logs the deployment fault it previously reported to nobody. `prompt=none` moves off the shared authorize URL onto the issue round-trip alone. Sign-in is where the first authorisation always happens, so it is the one path that cannot rest on an undocumented Discord behaviour — and it is also where an account authorised under 0186's `identify` must be re-shown the consent screen to grant `guilds.members.read` at all. The rest: `describeWait` clamps a long wait into a bigger unit instead of rejecting it into "a few minutes"; `?issue=ok` no longer sits above a failed reveal, nor above a usage section offering to issue a second key; "Check again" says it is checking; `?issue=failed`'s copy stops claiming a check ran, since it is now reachable before one; and CI check 7(b) resolves `Fn::Join`/`Fn::Sub` parameter names rather than skipping them, refusing any name whose last segment it cannot read. The issue path's usage-cache eviction was asserted nowhere — deleting it left the workspace green, resurrecting 0188's R2 on the one page load that follows an issue. It has a test now, proven non-vacuous. Workspace 553 -> 558, app.spec.tsx 56 -> 61.
PR #230's five findings and two risks, the five problems the pass over the whole diff turned up, and decisions 23 to 26 for the choices taken without asking: the `prompt=none` narrowing, the redirect-only issue path, the re-worded `?issue=failed`, and `?issue=ok` reporting the key's existence by itself. Records one finding as raised-not-taken (the same `502` dead end on the sign-in arm, which is 0186's code and 0186's documented decision), and the rebase onto 0188's tip as the remaining pre-merge step.
…at/0189_eligibility-gate-discord-membership-and-account-age Brings 0188's four review rounds under 0189, which was cut before them. The reviewer's closing note on #230 asked for this before merge. Fourteen files conflicted. Ten were the same mechanical hunk — 0188 added `AppConfig::portal_rate_limit`, 0189 added `portal_eligibility`, and every test that builds the struct literally saw both. Both fields kept. Three needed a decision: - `compute-stack.ts` — both slices append to the api-handler's environment; both blocks kept, so the template now carries PORTAL_GUILD_ID_PARAM, PORTAL_MIN_ACCOUNT_AGE_PARAM and PORTAL_RATE_LIMIT together. Synth confirms all three. - `app.spec.tsx` — 0189 refactored two failure tests onto the `signedInWithoutKey` helper that 0188 still wrote out inline. Helper form kept; 0188's name for one of them ("leaves the button pressable") described a button 0189 replaced with a link. `signedInWithoutKey` also moves onto 0188's `openConfig`, which its sibling had already taken in the automatic merge — split, the no-key dashboard would have rendered without the rate-limit line the backend now serves from /config. - Two of 0188's O3 tests drove that same button and so failed on the merged tree, which git could not see because 0189 never touched their text. The property they protect is unchanged — a key reaching the screen while the mount-time usage load is still in flight must still trigger the refetch, and must trigger it once — so they were rewritten to 0189's arrival path (the read-only reveal resolving on mount) rather than deleted. Both still fail if the effect is broken. The effect itself merged cleanly and correctly: 0188's O3 version, watching `view.state`, supersedes 0189's C4 ref-read exactly as its own review round decided, and 0189's `?issue=ok` hook feeds `keyOnScreen` alongside it. Its comment is updated to name both arrivals instead of a press. Workspace 570 passed, portal 76 passed, clippy clean, synth + openapi checks green.
…ship-and-account-age #227 put 0188 on develop, so this branch retargets there — and develop had moved eleven commits further (0190, 0204, 0213, 0120, 0135) while this slice was in review. One conflict, in 0189's own task file: develop carries the stub as it stood at `chore(lore-0189): activate task`, this branch carries the implementation record written on top of it. Ours wholesale — everything develop's copy has is a subset, apart from the older parameter-table wording that decision #21 deliberately corrected (`stellar_test` as a guild id was the trap that refused every visitor). Workspace 610 passed, portal 76 passed, clippy clean, fmt/format, synth and the openapi checks all green.
rustc 1.98.0, released 2026-08-18, began passing `-Wl,--fix-cortex-a53-843419` on aarch64-unknown-linux-gnu. Zig's linker rejects the argument outright, so every aarch64 link fails and the `Build Lambda bootstraps` step dies on the first crate that links — `crc-fast`, pulled in by aws-smithy-checksums. Nothing in this repo caused it: PR #227 passed on 1.97.1 and PR #230 failed on 1.98.0 with an identical Cargo.lock and the same cargo-lambda 1.9.1 / zig 0.16.0 pair. `develop` did not show it because its rust job has not run since 22 July. cargo-zigbuild filters the argument since v0.23.0 (rust-cross/ cargo-zigbuild #451, fixed by #452), but cargo-lambda 1.9.1 still vendors 0.20.1 — so upgrading cargo-lambda cannot reach the fix today. Both pins come off together once a cargo-lambda release carries zigbuild >= 0.23. cargo-lambda is pinned as well because `pip3 install cargo-lambda` resolves at run time, which is how the vendored zigbuild version — the half that actually carries the fix — could change under a green build without a commit. Considered and rejected: `cargo lambda build --compiler cargo` on the native ARM runner. ubuntu-24.04 ships glibc 2.39 against provided.al2023's 2.34, so the failure would move from CI to the deployed function.
Both shipped to `develop` today: 0188 in PR #227 (`a76d8a9`), 0189 in PR #230 (`99bca3a`, approved by Oskar Karcz). Workspace ends at 558 Rust tests and 61 portal tests, 0 failures. 0188 closes seven of eight criteria; "N requests move the number" cannot be closed from a keyboard and waits on the deploy with 0187's live curl. Carried forward from its four review passes: the cached "no key" that survived the issue falsifying it, the eviction race that followed, and the running-balance reset detection that keeps `used` and `remaining` in one AWS period — the only evidence this system can produce about the instant ADR 0010 correction #2 is open on. 0189 closes all twelve criteria in code, and deliberately does not close Step 0: the five Discord measurements are operator-owned, the tables keep a dated deferral, and no result was invented. The two findings worth carrying are written into the task — `prompt=none` asserted by three comments and sent by no code, and one `is_snowflake` now guarding both the cold-start probe and the member URL. Still operator-owned before production: the Developer Portal scope, the two SSM seeds, and 0179 step 4. Nothing spawned; every follow-up already has an owner.
Eligibility gate — Discord membership and minimum account age (lore task 0189)
The whole of the epic's abuse story. Until this lands, lore task 0187 issues a key
to anyone holding a Discord account — acceptable on a dev distribution, and not
something that may reach production.
What it does
A key is issuable only by a member of the configured Discord guild whose account is
older than a configured minimum. Both facts are proved per action, by a fresh
OAuth round-trip, and neither is ever carried in the session (ADR 0010 §8) — a signed
"eligible" claim would date the verdict to sign-in time.
pending === false) + account ageThe last two rows are documented in
eligibility.rsbefore those slices exist, sothey inherit the table rather than re-deciding it.
The structural change
/keyis now fully read-only — no create, no attach, no delete.GETandPOSTboth reach the same reveal. The create-capable reconciler survives as
issue_for,reachable only from the
action=issuecallback.That makes "issue is unreachable with a session cookie alone" a property of the call
graph rather than a guard someone can remove: a session cookie can cause zero
control-plane writes. It also retires 0187's
SameSite=LaxGET-may-create argument.Costs accepted and documented at the module: a hand-deleted key answers
no_keyinstead of resurrecting, an unattached orphan reveals un-repaired, duplicates wait
for the next issue to converge. Each heals on one gated press.
Three outcomes, not two
Only Discord's own
10007/10004on a404reads as "not a member". A401,403,429,5xx, an unrecognised404body, or an absentpendingfield is unknown:refuse, but never claim non-membership. "Could not verify" is fixable by waiting;
"you are not a member" is an accusation the visitor can only disprove by joining a
server they may already be in.
?issue=failedis kept separate from?issue=unknownfor the same reason — one saysDiscord could not vouch for you, the other says you are fine and our key service was
not.
Two operator-seeded SSM parameters
/prices/{env}/discord-guild-idand/prices/{env}/min-account-age-minutes, resolvedper issuance through the Parameters and Secrets extension, so an operator's
put-parametertakes effect without a redeploy (the extension's ~5 min cache is theonly delay). Probed once at cold start, so a bad seed is an
Init Errorsevent withthe parameter named rather than a per-visitor refusal.
Nothing creates them. A CloudFormation-managed parameter is CDK-owned, so the next
cdk deploywould silently restore the committed value — un-flipping production backto the test guild after lore task 0179 step 4.
verify-openapi-routes.mjscheck 7refuses any synthesized template that would create either, and both halves of the
check are non-vacuity-tested.
Review round
A code review returned seven findings. Each was verified against the code before
acting; none was a false positive. Five were fixed directly, two after confirming no
future task owned them.
RECONCILE_DEADLINEwas sizedfor 0187, where the reconciliation was the request. With four network calls in
front of it the worst case reached ~29s against a 15s function — an API Gateway
502instead of?issue=failed, and possibly a key created but never attached.The deadline is now derived from what is left of the invocation, measured from
request entry, with a floor below which no reconciliation starts.
prompt=nonewas missing. Three comments asserted that Discord does notre-prompt for consent on repeat authorisation; the parameter that makes that true
was never sent. Every issue, every retry after a refusal and every future rework
was a full consent screen — the cost the per-action model's own justification
denied.
?issue=cancelledand?issue=denied. Not a sixth and seventh verdict: thefive states are outcomes of a completed check, these happen before one starts,
and sign-in has had exactly this pair since 0186. Issue had neither, so a cancelled
press landed on
?signin=cancelled— whose banner renders only in the signed-outbranch an issue round-trip has by definition left.
is_snowflake, shared. The seed was validated in one place and consumed inanother, and only the consumer checked the shape, so a guild name passed the
cold-start probe and then refused every visitor as
unknown, indefinitely. Thetask's own parameter table named such a value and is corrected with it.
GET /keyfired twice per load (measured), becauseloaddepended on aninline callback prop. And
?issue=okcould render beside "you have no API key yet"in
GetApiKeys's eventual-consistency window — offering a second key to somebodywho had just been given their first.
Deliberately not fixed here: the wildcard
apigateway:DELETEon/apikeys/*. It isa real weakness — "own" is enforced only in code — but lore task 0194's checklist
already names it verbatim, mitigation included, and fixing it here would remove the
audit's subject.
Still open, and owned by the operator
Every acceptance criterion this slice's code can meet is met and asserted by a test.
Two items that were previously carried in that list are not criteria for code at all
and have been dropped from it: Step 0's five measurements (lore task 0180 items 1–5)
and the consent-screen captures need a second guild with screening off and a second
non-member account — manual prerequisites owned by the operator. They stay recorded
in Step 0's status note, in the task's Future Work, and in the two runbooks that
carry the procedure. The archived result tables were checked and are empty
placeholders, so nothing was carried in and nothing was invented.
The code is written to the documented safe rules instead: only a confirmed
10007/10004reads as non-membership, everything else refuses without accusation,and an absent
pendingnever passes. Either measured outcome changes at most onematch arm each (Design Decisions 4-6 in the task).
Verified
cargo fmt --all --check·cargo clippy --workspace --all-targets(0 warnings inprices-api) ·
cargo test --workspace(553 passed, 0 failed) ·cargo check -p prices-api --features lambda(every local seam compiled out) ·nx run-many -t lint typecheck build test(65 frontend tests) ·nx format:check --all·make -C infra synth-production·openapi:lint·openapi:verify-routes(check 7 live) ·openapi:verify-servers🤖 Generated with Claude Code